Struct Vs Class When To Use Which Β· How it works
5 min read- How it works
- Reference semantics vs value semantics
- "Structs live on the stack" is a simplification
- Boxing
- Nullability
- Inheritance limits
- Constructors, `default`, and the C# 10β12 changes
- `readonly struct`, `readonly` members, `record struct`
- When to choose which (Microsoft's guidance)
- π§© Real-World Example (HFM context)
How it works
_Facts checked against Microsoft Learn: "Structure types", "Choosing Between Class and Struct" (Framework Design Guidelines), 2026-09-22._
Reference semantics vs value semantics
A variable of a class type holds a reference to an instance on the managed heap. Assigning it, passing it or returning it copies the reference, so two variables can point at the same object and a change through one is visible through the other.
A variable of a struct type holds the instance itself. Assignment, passing an argument and returning a value each copy the whole instance, so every variable owns an independent copy. Microsoft's reference page says it plainly: because structs have value semantics, define them immutable.
"Structs live on the stack" is a simplification
A struct lives inline in whatever contains it:
| Where the struct is declared | Where its bytes are |
|---|---|
| Local variable / parameter | The method's stack frame (or a CPU register after JIT) |
| Field of a class | Inside that class instance, on the heap |
Element of Point[] | Inline in the array's heap block β no per-element object |
Captured by a lambda, or a local in an async/iterator method | Hoisted into a compiler-generated class β heap |
Cast to object or to an interface it implements | Boxed: a new heap object holding a copy |
Framework Design Guidelines: value types are "allocated either on the stack or inline in containing types and deallocated when the stack unwinds or when their containing type gets deallocated." So the useful claim is "no separate allocation", not "always on the stack".
Boxing
Converting a struct to object, ValueType or an interface allocates a box on the heap and copies the value into it; unboxing copies it back out. Mutating the box never touches the original. Common hidden sources: non-generic collections (ArrayList), string.Format(object) overloads, calling an interface method through the interface type, and the default ValueType.Equals(object). Generics with a where T : struct or IEquatable<T> constraint avoid it.
Nullability
A struct variable can't be null; its "empty" state is default(T) β every field zeroed. To model "missing", use Nullable<T> (T?), which adds a HasValue flag around the value. A class variable can be null, and with nullable reference types enabled the compiler tracks that for you.
Inheritance limits
A struct implicitly derives from System.ValueType, is implicitly sealed, can't inherit from another struct or class, and can't be a base type. It can implement interfaces. It can't declare a finalizer. So: no virtual/protected members, no polymorphic hierarchy.
Constructors, default, and the C# 10β12 changes
- Every struct has a
publicparameterless constructor. From C# 10 you may write your own, but it must bepublic. default(T)and array creation (new T[30]) ignore that constructor and produce the all-zero value. Onlynew T()runs it.- If a struct has a field initializer it must declare at least one constructor.
- C# 11 (auto-default structs): a constructor no longer has to assign every field; unassigned fields are zeroed for you.
- C# 12: structs can have primary constructors.
readonly struct, readonly members, record struct
readonly struct: every field must bereadonlyand every property get-only orinit. The compiler then knows no member mutatesthis, so it can skip defensive copies when the struct is reached through aninparameter or areadonlyfield. It's shallow: aList<T>field can't be replaced, but you can stillAddto it.readonlyon an individual method/property marks just that member as non-mutating.record struct(C# 10): the compiler generates value equality (Equals,GetHashCode,==,!=,IEquatable<T>),ToString, andDeconstruct. Its positional properties are read-write; usereadonly record structto make theminit-only.withexpressions work on any struct (C# 10), not just records:var p2 = p1 with { X = 3 };.
When to choose which (Microsoft's guidance)
The Framework Design Guidelines say: CONSIDER a struct if instances are small and commonly short-lived or commonly embedded in other objects. AVOID a struct unless the type has all of these:
- It logically represents a single value, like a primitive (
int,double). - Its instance size is under 16 bytes.
- It is immutable.
- It will not have to be boxed frequently.
"In all other cases, you should define your types as classes." The page itself notes it is reprinted from the 2008 second edition and that the book has since been fully revised in a third edition; treat 16 bytes as a rule of thumb for copy cost, and measure (BenchmarkDotNet) before going over it. ValueTuple, Span<T>, DateTime, Guid (16 bytes) and decimal (16 bytes) are the BCL's own examples.
π§© Real-World Example (HFM context)
If youβre processing millions of tick messages per second:
- Use a
struct(orreadonly struct) for individual ticks (lightweight, immutable). - Use a
classfor services and entities that manage state, likeOrderBook,TradeSession, orCacheManager.
Size check: on 64-bit this
Tickis 24 bytes (an 8-bytestringreference + two 8-bytedoubles), above Microsoft's "under 16 bytes" guideline. It can still be the right call for a hot path β no per-tick allocation β but pass it withinto avoid copying 24 bytes per call, and measure. ReplacingSymbolwith a small integer id brings it back under 16.
Example:
readonly struct Tick
{
public string Symbol { get; init; }
public double Bid { get; init; }
public double Ask { get; init; }
}
class PriceFeedProcessor
{
private readonly List<Tick> _ticks = new();
public void OnTick(Tick tick) => _ticks.Add(tick);
}